#!/usr/bin/env python2
from __future__ import unicode_literals, print_function

import itertools as it, operator as op, functools as ft
from os.path import basename, dirname
from collections import OrderedDict, namedtuple
from tempfile import NamedTemporaryFile
import os, sys, types, errno, stat, re, logging

from onedrive import api_v5, conf
import fuse


from datetime import datetime, timedelta
from calendar import timegm
try:
	import iso8601
	def parse_ts(ts):
		return timegm(iso8601.parse_date(ts).utctimetuple())
except ImportError:
	def parse_ts(ts):
		try: (ts, tz), tz_mul = ts.rsplit('+', 1), 1
		except ValueError: (ts, tz), tz_mul = ts.rsplit('-', 1), -1
		ts_utc = datetime.strptime(ts, '%Y-%m-%dT%H:%M:%S')
		ts_utc += timedelta(0, (int(tz[:2])*3600 + int(tz[2:])*60) * tz_mul)
		return timegm(ts_utc.utctimetuple())



class CacheLRU(object):

	CE = namedtuple('CachedException', 'err')
	hits = misses = 0

	def __init__(self, func, cache_exceptions=True, maxsize=100):
		self.func, self.maxsize, self.ce = func, maxsize, cache_exceptions
		self.cache = OrderedDict()

	@staticmethod
	def _key(argz, kwz):
		return kwz.pop('_cache_key', False)\
			or (argz + tuple(sorted(kwz.viewitems())))

	def __call__(self, *argz, **kwz):
		key = self._key(argz, kwz)
		try:
			res = self.cache.pop(key)
			self.hits += 1
		except KeyError:
			try: res = self.func(*argz, **kwz)
			except Exception as err:
				if not self.ce: raise
				res = self.CE(err)
			self.misses += 1
			while len(self.cache) >= self.maxsize:
				self.cache.popitem(0)
		self.cache[key] = res
		if isinstance(res, self.CE): raise res.err
		else: return res

	def purge(self, *argz, **kwz):
		self.cache.pop(self._key(argz, kwz), None)


class PerPathCache(CacheLRU):
	@staticmethod
	def _key(argz, kwz): return argz[0]
	def __call__(self, path):
		return super(PerPathCache, self).__call__(path)
	def purge(self, path):
		return super(PerPathCache, self).purge(path)



class OneDriveFS(fuse.LoggingMixIn, fuse.Operations):


	def __init__( self,
			api, root='me/skydrive', log=None,
			api_cache=True, api_cache_scale=1.0 ):

		self.api, self.root = api, root
		self.log = log or logging.getLogger(
			'{}.{}'.format(__name__, self.__class__.__name__) )

		if api_cache and api_cache_scale > 0:
			self._resolve = PerPathCache(
				self._resolve_path, maxsize=int(200 * api_cache_scale) )
			self._info = PerPathCache(
				self.api.info, maxsize=int(100 * api_cache_scale) )
			self._listdir = PerPathCache(
				self.api.listdir, maxsize=int(50 * api_cache_scale) )
		else:
			self._resolve, self._info, self._listdir =\
				self._resolve_path, self.api.info, self.api.listdir

		self.created = set()

	def _resolve_path(self, path):
		return self.api.resolve_path(path, root_id=self.root)

	def _purge(self, *entries, **kwz):
		caches = kwz.keys() or ['info', 'listdir']
		for entry in entries:
			if isinstance(entry, types.StringTypes): ec = caches
			else: entry, ec = entry
			if isinstance(ec, types.StringTypes): ec = [ec]
			for cache in ec:
				# print('PURGE', entry, cache)
				op.attrgetter('_{}.purge'.format(cache))(self)(entry)


	def __call__(self, op, *args):
		try: res = super(OneDriveFS, self).__call__(op, *args)
		except api_v5.DoesNotExists:
			raise fuse.FuseOSError(errno.ENOENT)
		except api_v5.ProtocolError as err:
			if err.code in [401, 403]: raise fuse.FuseOSError(errno.EACCES)
			elif err.code == 404: raise fuse.FuseOSError(errno.ENOENT)
			raise
		return 0 if res is None else res


	def _isdir(self, obj_id):
		return self._info(obj_id).get('type') in ['folder', 'album']


	def getattr(self, path, fh=None):
		obj = self._info(self._resolve(path))\
			if path not in self.created else dict(type='file', size=0)
		obj_mtime = obj.get('updated_time') or obj.get('created_time')
		return dict( (k,v) for k,v in [
			( 'st_mode', (stat.S_IFDIR | 0755)
				if obj['type'] in ['folder', 'album'] else (stat.S_IFREG | 0644) ),
			('st_mtime', obj_mtime and parse_ts(obj_mtime)),
			('st_nlink', 2), ('st_size', obj.get('size')) ] if v is not None )

	def access(self, path, mode):
		self._resolve(path)

	def readdir(self, path, fh):
		return ['.', '..'] + map( op.itemgetter('name'),
			self._listdir(self._resolve(path)) )

	def statfs(self, path):
		free, quota = self.api.get_quota()
		return dict( f_bavail=quota-free, f_bfree=free,
			f_blocks=quota, f_bsize=1, f_favail=-1, f_ffree=-1,
			f_files=-1, f_flag=0, f_frsize=1, f_namemax=256 )


	def mkdir(self, path, mode):
		name, parent = basename(path),\
			self._resolve(dirname(path)) or self.root
		self.api.mkdir(name, parent)
		self._purge(parent)

	def rename(self, old, new):
		if old == new: return
		new_dir, old_dir = dirname(new), dirname(old)
		old_id = self._resolve(old)
		if new_dir != old_dir:
			self.api.move(old_id, self._resolve(new_dir))
			self._purge(old_dir, (old, 'resolve'))
		# If remote path with same name exists, delete it
		try: self.unlink(path) # to raise EISDIR, if necessary
		except api_v5.DoesNotExists: pass
		self.api.info_update(old_id, dict(name=basename(new)))
		self._purge(new_dir)

	def rmdir(self, path):
		path_id = self._resolve(path)
		if not self._isdir(path_id):
			raise fuse.FuseOSError(errno.ENOTDIR)
		elif self._listdir(path_id):
			raise fuse.FuseOSError(errno.ENOTEMPTY)
		self.api.delete(path_id)
		self._purge(dirname(path), (path, 'resolve'))

	def unlink(self, path):
		path_id = self._resolve(path)
		if self._isdir(path_id):
			raise fuse.FuseOSError(errno.EISDIR)
		self.api.delete(path_id)
		self.created.discard(path)
		self._purge(dirname(path), (path, ['resolve', 'info']))


	def read(self, path, size, offset, fh):
		path_id = self._resolve(path)
		return self.api.get(path_id, byte_range='{}-{}'.format(offset, offset+size))

	def create(self, path, mode, fi=None):
		if not self._isdir(self._resolve(dirname(path))):
			raise fuse.FuseOSError(errno.ENOTDIR)
		try: path_id = self._resolve(path)
		except api_v5.DoesNotExists: pass
		else:
			if self._isdir(path_id):
				raise fuse.FuseOSError(errno.EISDIR)
			if path_id and mode & (os.O_CREAT and os.O_EXCL):
				raise fuse.FuseOSError(errno.EEXIST)
		self.created.add(path)

	def write(self, path, data, offset, fh):
		path_dir = dirname(path)
		try: path_id = self._resolve(path)
		except api_v5.DoesNotExists: path_id = None

		# Create/modify file, upload it with tmp name
		with NamedTemporaryFile() as tmp:
			if path_id: tmp.write(self.api.get(path_id))
			if offset: tmp.seek(offset)
			tmp.write(data)
			tmp.flush()
			res = self.api.put( tmp.name,
				self._resolve(path_dir), overwrite='ChooseNewName' )

		# Set proper name
		try:
			if path_id: self.api.delete(path_id)
			self.api.info_update(res['id'], dict(name=basename(path)))
		finally:
			self.created.discard(path)
			# Always drop path_dir caches, just to be safe
			self._purge((path, ['resolve', 'info', 'listdir']), (path_dir, ['info', 'listdir']))

		return len(data)

	def truncate(self, path, length, fh=None):
		if path in self.created: return
		path_id = self._resolve(path)

		# Modify file, upload it with tmp name
		with NamedTemporaryFile() as tmp:
			tmp.write(self.api.get(path_id))
			tmp.truncate(length)
			res = self.api.put( tmp.name,
				self._resolve(path_dir), overwrite='ChooseNewName' )

		# Set proper name
		try:
			self.api.delete(path_id)
			self.api.info_update(res['id'], dict(name=basename(path)))
		finally:
			# Always drop path_dir caches, just to be safe
			self._purge((path, ['resolve', 'info']), (path_dir, ['info', 'listdir']))




def main():
	opts_fs = dict(api_cache=True, api_cache_scale=1.0)

	import argparse
	parser = argparse.ArgumentParser(
		description='Mount OneDrive as a FUSE filesystem.')
	parser.add_argument('config',
		metavar='config_path[:onedrive_path]',
		nargs='?', default=conf.ConfigMixin.conf_path_default,
		help='Writable configuration state-file (yaml).'
			' Used to store authorization_code, access and refresh tokens.'
			' Should initially contain at least something like "{client: {id: xxx, secret: yyy}}".'
			' Path might be appended to it after colon to make only that folder accessible.'
			' Default: %(default)s')
	parser.add_argument('mountpoint', metavar='path', help='Path to mount OneDrive to.')
	parser.add_argument('-o', '--mount-options',
		help=''.join([ 'Comma-separated list of mount options.',
			' Use "someflag=no", or "someflag=false" to disable binary flags.',
			' OneDriveFS options: {};'\
				.format( ', '.join('{} (default: {})'.format(k, v)
					for k,v in sorted(opts_fs.viewitems())) ),
				' the rest is passed to fuse.',
			' See "man mount.fuse" for the list of fuse-specific options.' ]))
	parser.add_argument('-f', '--foreground', action='store_true',
		help='Dont fork into background after mount succeeds.')
	parser.add_argument('--debug', action='store_true',
		help='Verbose operation mode. Implies --foreground.')
	optz = parser.parse_args()

	log = logging.getLogger()
	logging.basicConfig(level=logging.WARNING
		if not optz.debug else logging.DEBUG)

	opts_fuse = dict(foreground=optz.foreground or optz.debug)
	if optz.mount_options:
		for opt in optz.mount_options.strip().strip(',').split(','):
			if '=' in opt: opt, val = opt.split('=', 1)
			# elif opt.startswith('no_'): opt, val = opt[3:], False
			else: val = True
			if opt in ['api_cache']: # binary flags
				if isinstance(val, types.StringTypes):
					if val.lower() in ['yes', 'true', 'y']: val = True
					elif val.lower() in ['no', 'false', 'n']: val = False
					else: parser.error('Unrecognized flag ({!r}) value: {!r}'.format(opt, val))
				opts_fs[opt] = val
			elif opt in ['api_cache_scale']: # numeric options
				opts_fs[opt] = float(val)
			elif opt in []: # strings
				opts_fs[opt] = val
			else: opts_fuse[opt] = val
	log.debug('FS options: {}; FUSE: {}'.format(opts_fs, opts_fuse))

	if ':' in optz.config:
		optz.config, onedrive_path = optz.config.split(':', 1)
		if not optz.config: optz.config = conf.ConfigMixin.conf_path_default
	else: onedrive_path = None

	api = api_v5.PersistentOneDriveAPI.from_conf(optz.config)
	if not onedrive_path or not re.search(
			r'^(file|folder)\.[0-9a-f]{16}\.[0-9A-F]{16}!\d+'
			r'|folder\.[0-9a-f]{16}$', onedrive_path ):
		onedrive_path = api.resolve_path(onedrive_path or 'me/skydrive')

	fuse.FUSE(
		OneDriveFS(api, onedrive_path, log=log, **opts_fs),
		optz.mountpoint, **opts_fuse )

if __name__ == '__main__': main()
